feat(ai-proxy-multi): add semantic load-balancing algorithm#13676
feat(ai-proxy-multi): add semantic load-balancing algorithm#13676AlinsRan wants to merge 5 commits into
Conversation
f1618ea to
1f784f1
Compare
|
Is there a specific reason to log the full embedding response body here? If not, please avoid logging the entire body, since it may contain sensitive information. Logging only the status and a bounded, sanitized error summary would be safer. |
1f784f1 to
be76356
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new semantic load-balancing algorithm for ai-proxy-multi, allowing APISIX to select an LLM instance based on semantic similarity between the request prompt and per-instance example prompts, using an embeddings sidecar call and cached reference vectors.
Changes:
- Add semantic vector math + an embeddings batch client, and integrate semantic instance selection into
ai-proxy-multiwith fail-open fallback behavior. - Extend schema/docs to support
balancer.algorithm=semantic, per-instanceexamples/threshold/catchall, and anembeddingsconfiguration block (including encrypted auth fields). - Add test coverage for semantic math, schema validation, routing behavior, multimodal prompt extraction, and “do not forward client headers to embeddings”.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| t/plugin/ai-proxy-semantic.t | Unit tests for vector math helpers (normalize/dot/cosine/aggregate). |
| t/plugin/ai-proxy-semantic-schema.t | Schema validation tests for semantic config constraints. |
| t/plugin/ai-proxy-semantic-routing.t | End-to-end routing tests with mocked embeddings/LLM endpoints and fail-open cases. |
| docs/zh/latest/plugins/ai-proxy-multi.md | Chinese docs: add semantic algorithm config fields and usage examples. |
| docs/en/latest/plugins/ai-proxy-multi.md | English docs: add semantic algorithm config fields and usage examples. |
| apisix/plugins/ai-transport/http.lua | Add skip_client_headers support for sidecar calls (e.g., embeddings). |
| apisix/plugins/ai-proxy/semantic.lua | New pure-Lua vector math module used by semantic routing. |
| apisix/plugins/ai-proxy/schema.lua | Extend ai-proxy-multi schema with semantic/embeddings fields + encrypt_fields updates. |
| apisix/plugins/ai-proxy/embedding.lua | New embeddings batch client via ai-provider layer with bounded error handling. |
| apisix/plugins/ai-proxy-multi.lua | Implement semantic instance selection + reference-vector caching and prompt extraction. |
| apisix/plugins/ai-providers/base.lua | Wire skip_client_headers into header construction for provider requests. |
| apisix/plugins/ai-providers/azure-openai.lua | Add openai-embeddings capability path for Azure OpenAI provider. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
bbb4318 to
d3d9d4f
Compare
8867bf8 to
7b4da59
Compare
Add a `semantic` balancer algorithm to `ai-proxy-multi` that picks an LLM instance by the semantic similarity between the request prompt and each instance's configured `examples`, instead of by weight or hash. Each non-catchall instance declares `examples` describing the prompts it should serve. On the request path the plugin embeds those examples and the incoming prompt via a configurable OpenAI-compatible embedding endpoint, computes cosine similarity, aggregates per-instance scores, and routes to the highest-scoring instance that clears its threshold. When no instance clears its threshold — or embedding fails for any reason — the request fails open to the `catchall` instance (else the first instance), so a request always has a target and never 500s on the routing path. Reference vectors are embedded once per config version and cached; only the prompt is embedded per request. No vector database is required. New config: `balancer.algorithm=semantic`, `balancer.threshold`, `balancer.aggregation`, `balancer.expose_scores`, per-instance `examples` / `threshold` / `catchall`, and an `embeddings` block.
7b4da59 to
f7e8f18
Compare
|
[P2] Semantic routing bypasses the existing protocol adapter and therefore silently falls back for supported OpenAI Responses requests. |
extract_last_user_message read body.messages directly, so the semantic balancer silently fell back for every non-Chat client protocol: OpenAI Responses carries the prompt in body.input, Anthropic/Bedrock in structured content blocks. Read the last user turn through ai-protocols.get_messages() so all supported protocols are normalized. Add Responses and Anthropic routing regression tests.
|
Fixed in b7e61ca. You're right — Now it reads the last user turn through Added regression coverage: a Responses request ( |
Address review feedback on the semantic balancer config surface: - Group all semantic-only options under a single `semantic_opts` object (embeddings, threshold, fallback, debugging) instead of scattering them across balancer/instances/top-level, which cut the cross-field schema validation. - Replace the per-instance `catchall` boolean with `semantic_opts.fallback`, which names the fallback instance. It needs no `examples` and a real ranked instance can also serve as the fallback; drops the '<=1 catchall' and 'catchall must not set examples' checks. - Rename `expose_scores` to `debugging`: the name is not tied to scores, so future debug signals can reuse it. Also decouple the HTTP transport from ctx: construct_forward_headers now takes an explicit client_headers table (nil for internal requests) instead of ctx + a skip flag, so the transport carries no downstream-request coupling and the caller decides whether client headers are forwarded. Docs (en + zh) and tests updated.
Post-refactor cleanup from code review: - Update the stale 'catchall' comment to 'fallback'. - Rename test titles that still said 'catchall' to match the semantic_opts.fallback model (bodies were already migrated). - Add a schema test asserting the named fallback instance may also carry examples and participate in ranking.
…finite guard Pre-merge review follow-ups: - Lower the embeddings.timeout default from 10s to 3s. The query prompt is embedded synchronously on every request, so a 10s default meant an embedding-endpoint outage blocked each request 10s before failing open to the fallback. Document the per-request dependency on the field. - Add a routing test asserting a non-finite (1e999 -> inf) embedding component fails open to the fallback instead of poisoning the scores. - Remove a duplicated, misplaced pcall comment above the prompt truncation block.
Description
This PR adds a
semanticload-balancing algorithm toai-proxy-multi, so a route can pick an LLM instance by what the request is asking for rather than by weight (roundrobin) or by a hash key (chash).Problem
ai-proxy-multitoday can spread traffic across several LLM instances, but the choice is blind to the request. In practice the instances behind one route are rarely interchangeable — a frontier model, a small cheap model and a domain-tuned model differ by an order of magnitude in cost and latency. Today the only ways to send the right prompt to the right model are:model(leaks backend topology into every caller, and every model swap becomes a client-side change), orNone of these let a single endpoint accept
"model": "auto"and route on meaning.Proposal
Let each instance describe, in natural language, the kind of prompt it should serve. The gateway embeds those descriptions once, embeds the incoming prompt, and picks the closest instance by cosine similarity.
Key properties:
examplesare embedded in a single batch and cached in an lrucache keyed by route + plugin config version, so a config change invalidates immediately and a steady-state request only embeds the prompt.nullvector, non-numeric component, missing index, dimension mismatch (e.g. the embedding model was changed underneath), lrucache error — falls back to thecatchallinstance (or the first instance if none is marked). The routing path cannot return 500 because embeddings are unavailable.Use cases
1. Cost routing — send hard prompts to the expensive model, everything else to the cheap one.
The single most common motivation. Coding and reasoning go to a frontier model; translation and summarisation go to a small one; anything that matches neither still gets served.
{ "balancer": { "algorithm": "semantic", "threshold": 0.5 }, "embeddings": { "provider": "openai", "model": "text-embedding-3-small", "auth": { "header": { "Authorization": "Bearer $EMBEDDING_KEY" } } }, "instances": [ { "name": "reasoning", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "examples": ["write a python function", "debug this stack trace", "explain this algorithm"] }, { "name": "cheap", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o-mini" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "examples": ["translate this sentence", "summarize this paragraph"] }, { "name": "default", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o-mini" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "catchall": true } ] }Clients always POST
"model": "auto". Swappinggpt-4ofor a newer model, or re-balancing what counts as "hard", is a gateway config change — no client ships a new build.2. Domain specialisation — route to fine-tuned or self-hosted models per subject.
A support assistant fronted by one endpoint, backed by a billing-tuned model, a technical-troubleshooting model, and a general model as
catchall. The client does not need to know the taxonomy, and adding a fourth domain is one more instance with a fewexamples.3. Data-residency / compliance routing.
Prompts that look like they carry regulated content (
"examples": ["my social security number is", "here is the patient's diagnosis"]) route to a self-hosted instance with a tightthreshold; everything else goes to the public API viacatchall. Note this is best-effort routing, not a DLP control — it complements, and does not replace, a content-inspection plugin.4. Progressive model migration.
Point a new model at a narrow set of
examplesand give it a high per-instancethreshold, so it only receives prompts it is confidently a match for. Widen the examples (or lower the threshold) as confidence grows, and drop the old instance when its share reaches zero.expose_scoresshows the score distribution while tuning.Configuration
balancer.algorithmroundrobinsemanticbalancer.thresholdbalancer.expose_scoresfalseX-AI-Semantic-Route/X-AI-Semantic-Scoresresponse headersinstances[].examplescatchallinstances[].thresholdbalancer.thresholdfor this instanceinstances[].catchallexamplesembeddingsalgorithm=semanticembeddings.provideropenai/azure-openaiembeddings.modeltext-embedding-3-smallembeddings.endpointopenai; forazure-openaithe full URL incl. deployment path +api-versionembeddings.authheader/query; both are inencrypt_fieldsembeddings.timeout10000embeddings.ssl_verifytrueThe embedding request is issued through the existing ai-provider layer (
openai-embeddingscapability), so it reuses the same endpoint resolution, authentication and HTTP transport as the chat path rather than a second HTTP client. It is a self-contained sidecar call: the client's own request headers are deliberately not forwarded to the embedding provider.Debugging the routing decision
balancer.expose_scores(defaultfalse) makes the plugin report how each instance scored and which one it picked:X-AI-Semantic-Scoreslists every instance that hasexamples, highest score first — not just the winner, so you can see how close the runners-up came. Thecatchallhas noexamples, hence no similarity score, and does not appear.When nothing clears the threshold, the pick becomes
fallbackwhile the scores still show how close each one got — which is exactly what you need to calibratethreshold:If the headers are absent while
expose_scoresis on, the embedding step itself failed and the request was served by thecatchallbefore any score was computed.Since these headers reveal instance names to the caller, they are meant for tuning, not production. The same score list is always logged at
warnon the fallback path, with no configuration and nothing exposed to clients:Alternatives considered
embeddingsblock already allows pointing at a self-hosted OpenAI-compatible embedding server.Known limitation
semanticselects an instance but does not participate in health checks orfallback_strategy/ retry: if the chosen instance's upstream then fails, that failure is returned to the client. It only falls back when routing is inconclusive (no instance clears its threshold) or embedding fails. Combining semantic selection with health-aware failover is a follow-up; the behaviour is stated in the schema description and the docs so it is not a surprise.Tests
t/plugin/ai-proxy-semantic.t(similarity math, incl. zero-vector and empty-aggregate edges),t/plugin/ai-proxy-semantic-schema.t(6 negative schema cases),t/plugin/ai-proxy-semantic-routing.t(14 cases): routing by intent, catchall fallback, and one case per fail-open guard — malformed (null) vector, non-table 200 body, non-200 response, dimension mismatch, reference-embedding failure — plus multimodalcontentarrays and a regression test asserting the client'sCookienever reaches the embedding endpoint.Which issue(s) this PR fixes:
N/A — new feature.
Checklist
docs/enanddocs/zh)roundrobin/chashroutes are unaffected; every new field is optional andsemanticmust be opted into)